Canvas 手把手寫一個線條動畫

先上效果圖,這個動畫相信你們在不少地方見過。可能樣式稍有不一樣,但大致一致。原做者不知是誰,看着仍是挺炫酷的,話很少說,下面開始。 css

搭建環境

使用TypeScript開發(本身的ts練習項目)。live-server做爲開發服務器,一切從簡。html

不會TS的同窗也不用擔憂。ts代碼不多。不影響閱讀。typescript

建立一個項目文件夾並進入打開命令行。npm

安裝依賴包

ts的編譯器和開發服務器(提供自動刷新能力)。直接全局安裝。json

npm i -g live-server typescript
複製代碼

初始化

tsc --init
npm init
複製代碼

目錄結構

配置文件

package.jsoncanvas

{
  "devDependencies": {},
  "scripts": {
    "dev": "live-server ./dist | tsc -watch"
  }
}
複製代碼

tsconfig.json瀏覽器

{
  "compilerOptions": {
    "incremental": true,
    "target": "es5",
    "module": "commonjs",
    "lib": [
      "es2020",
      "dom"
    ],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  }
}
複製代碼

啓動項目

啓動後自動打開瀏覽器bash

npm run dev
複製代碼

開始編寫

啓動項目後。在src下index.ts文件在代碼保存後會直接編譯到dist文件夾中。頁面也會自動刷新服務器

頁面結構

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>document</title>
    <style> body, html { height: 100%; } body { margin: 0; background: #eee; } canvas { height: 100%; width: 100%; } </style>
  </head>
  <body>
    <canvas id="cvs"></canvas>
  </body>
  <script src="./index.js"></script>
</html>
複製代碼

第一步讓畫布全屏

這裏比較簡單,沒啥好說的。dom

要注意的就是canvas畫布的大小是由標籤上的屬性(width/height)決定的,css的寬高決定是這個元素的顯示大小。相似於一張1920*1080的圖片你讓它在一個100*100的<img />中顯示同樣,因此css的大小與屬性width/height一致最好

/// <reference path="./index.d.ts" />
let cvs = document.getElementById("cvs");
// 此處用類型保護將cvs肯定爲 HTMLCanvasElement 類型
if (cvs instanceof HTMLCanvasElement) {
  const ctx = cvs.getContext("2d")!;
  // 畫布大小
  let width: number = 0;
  let height: number = 0;
  // 設置畫布大小與窗口同樣大
  const setSize = () => {
    // 獲取當前文檔內容區寬高
    width = document.documentElement.clientWidth;
    height = document.documentElement.clientHeight;
    // 類型保護只在上個做用域生效,因此這裏再寫一次
    if (cvs instanceof HTMLCanvasElement) {
      // 設置canvas的實際寬高
      cvs.width = width;
      cvs.height = height;
    }
  };
  window.onresize = setSize;
  setSize();
}
複製代碼

第二步生成指定數量小球

小球有本身x,y座標和加速度以及半徑,這些屬性都是後面繪製所須要的

// 小球數量
const dotNum = 50;
// 小球列表
const dotList: Array<TDot> = [];
// 隨機數
const random: TRandom = (min, max) =>
  Math.floor(Math.random() * (max - min + 1) + min);
// 隨機生成 1 或 -1
const randomSign = () => [-1, 1][random(0, 1)];
for (let i = 0; i < dotNum; i++) {
  dotList.push({
    // 隨機座標(4 是圓半徑)
    x: random(4, width - 4),
    y: random(4, height - 4),
    // 隨機加速度(randomSign 用來讓加速度有正反值,球就有不一樣的方向)
    xa: Math.random() * randomSign(),
    ya: Math.random() * randomSign(),
    // 圓點半徑
    radius: 4
  });
}
複製代碼

第三步讓小球動起來

這裏用了requestAnimationFrame,它接收一個回調函數並在頁面下一幀刷新前調用,這個API是作動畫最經常使用方法。

這裏要注意每一幀繪製前要清空前一次繪製的畫面。用clearRect(),還有每次畫路徑前要調用beginPath()方法以從新開始一條路徑

// 繪製函數
const draw = () => {
  // 清空上次繪製
  ctx.clearRect(0, 0, width, height);
  dotList.forEach((dot, index) => {
    // 計算下一幀的座標
    dot.x += dot.xa;
    dot.y += dot.ya;
    // 設置小球的顏色
    ctx.fillStyle = "#6cf";
    // 畫小球路徑
    ctx.beginPath();
    ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
    // 填充顏色
    ctx.fill();
  });
  requestAnimationFrame(draw);
};
// 動畫開始
draw();
複製代碼

效果圖,此時小球按各自的速度和方向運動。但不一會就會走出屏幕看不見

第四步添加邊界檢測

修改draw函數,經過canvas的寬高計算小球座標的最大和最小值來進行邊界檢測並添加反彈效果

const draw = () => {
  // 清空上次繪製
  ctx.clearRect(0, 0, width, height);
  dotList.forEach((dot, index) => {
    // 計算下一幀的座標
    dot.x += dot.xa;
    dot.y += dot.ya;
    // 計算邊界值
    const Xmin = dot.radius;
    const Xmax = width - dot.radius;
    const Ymin = dot.radius;
    const Ymax = height - dot.radius;
    // 判斷下一幀座標是否越界,越界則將加速度取反,小球就能夠在邊緣反彈。
    (dot.x >= Xmax || dot.x <= Xmin) && (dot.xa = -dot.xa);
    (dot.y >= Ymax || dot.y <= Ymin) && (dot.ya = -dot.ya);
    // 將越界座標矯正(超過邊界值就設爲邊界值)
    dot.x = dot.x >= Xmax ? Xmax : dot.x <= Xmin ? Xmin : dot.x;
    dot.y = dot.y >= Ymax ? Ymax : dot.y <= Ymin ? Ymin : dot.y;
    // 設置小球的顏色
    ctx.fillStyle = "#6cf";
    // 畫小球路徑
    ctx.beginPath();
    ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
    // 填充顏色
    ctx.fill();
  });
  requestAnimationFrame(draw);
};
複製代碼

效果圖

第五步讓小球連線

設計連線的規則:

  • 兩個小球之間距離小於指定值時就在之間畫一條線
  • 線條粗細和透明度隨着距離變近變得粗和不透明

要將全部小球兩兩計算距離,只需在dotList每次遍歷中再將後續的小球與當前小球進行計算,這樣就能在一次繪製中將全部小球每兩個計算一遍。

而距離的計算只需用一個簡單的勾股定理,如圖,a²+b²=c²

增長distSquare變量並修改draw函數,這裏畫小球代碼之因此放在最後是防止線條繪製在小球上面影響美觀

// 預設距離值(平方值)
const distSquare = 10000;
// 繪製函數
const draw = () => {
  // 清空上次繪製
  ctx.clearRect(0, 0, width, height);
  dotList.forEach((dot, index) => {
    /** ...省略部分代碼 **/
    // 小球之間連線
    for (let i = index + 1; i < dotList.length; i++) {
      // dot後面的小球
      let nextDot = dotList[i];
      // 計算兩個小球的x 與 y 座標差值
      let x_dist = dot.x - nextDot.x;
      let y_dist = dot.y - nextDot.y;
      // 計算斜線長度
      let dist = x_dist * x_dist + y_dist * y_dist;
      // 兩點距離小於預設值則讓兩點連線
      if (dist < distSquare) {
        drawLine(dist, dot, nextDot);
      }
    }
    // 設置小球的顏色
    ctx.fillStyle = "#6cf";
    // 畫小球路徑
    ctx.beginPath();
    ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
    // 填充顏色
    ctx.fill();
  });
  requestAnimationFrame(draw);
};
複製代碼

實現連線函數

// 將兩個小球進行連線(參數:兩點距離(平方值)、當前小球、下一個小球)
const drawLine: TDrawLine = (dist, dot, nextDot) => {
  // 距離差值 與 預設距離的比例計算透明度,距離越近越不透明
  let op = (distSquare - dist) / distSquare;
  // 計算線條寬度
  const lineWidth = op / 2;
  ctx.lineWidth = lineWidth; 
  // 設置線條顏色和透明度
  ctx.strokeStyle = `rgba(20, 112, 204,${op + 0.2})`; 
  // 畫路徑
  ctx.beginPath();
  ctx.moveTo(dot.x, dot.y);
  ctx.lineTo(nextDot.x, nextDot.y);
  // 畫線
  ctx.stroke();
};
複製代碼

上圖

第六步鼠標跟隨效果

首先實時獲取鼠標座標

// 鼠標座標(-1表示不在窗口中)
let point: Point = { x: -1, y: -1 };
// 鼠標座標實時獲取
window.addEventListener("mousemove", ({ clientX, clientY }) => {
  point = { x: clientX, y: clientY };
});
// 移出窗口座標清除
window.addEventListener("mouseout", () => {
  point = { x: -1, y: -1 };
});
複製代碼

而後修改draw函數,加入與鼠標的連線以及範圍跟隨。

// 繪製函數
const draw = () => {
  // 清空上次繪製
  ctx.clearRect(0, 0, width, height);
  dotList.forEach((dot, index) => {
    /** ......省略部分代碼 **/

    // 小球與鼠標之間連線(不爲-1表示鼠標在裏面)
    if (point.x !== -1) {
      // 計算鼠標與當前小球座標差值
      let x_dist = point.x - dot.x;
      let y_dist = point.y - dot.y;
      // 計算鼠標與當前小球直線距離
      let dist = x_dist * x_dist + y_dist * y_dist;
      // 小於預設值(能夠連線)
      if (dist < distSquare) {
        // 大於等於 預設值的一半 小於預設值(範圍是個外圓圈) 加速向鼠標
        if (dist >= distSquare / 2) {
          dot.x += 0.02 * x_dist;
          dot.y += 0.02 * y_dist;
        }
        drawLine(dist, dot, point);
      }
    }
    // 設置小球的顏色
    ctx.fillStyle = "#6cf"; // 畫小球路徑
    ctx.beginPath();
    ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2); // 填充顏色
    ctx.fill();
  });
  requestAnimationFrame(draw);
};
複製代碼

這裏比較難理解的是小球加速向鼠標的代碼

// 小於預設值(能夠連線)
if (dist < distSquare) {
  // 大於等於 預設值的一半 小於預設值(範圍是個外圓圈) 加速向鼠標
  if (dist >= distSquare / 2) {
    dot.x += 0.02 * x_dist;
    dot.y += 0.02 * y_dist;
  }
  drawLine(dist, dot, point);
}
複製代碼

最裏面的判斷是當小球座標位於鼠標外圍圓圈中時。把小球的座標加上 與鼠標座標差值的百分之二,小球速度會明顯變快。

而爲何會朝向鼠標:

當小球在鼠標左邊時,座標差值是正數,向右加速運動。

當小球在鼠標右邊時,座標差值是負數,小球向左加速運動。上下同理。

而小球座標加上的值是差值的百分比。因此朝向就是鼠標。

至此功能所有完成。

完整代碼

index.ts

/// <reference path="./index.d.ts" />

let cvs = document.getElementById("cvs");

// 此處用類型保護將cvs肯定爲 HTMLCanvasElement 類型
if (cvs instanceof HTMLCanvasElement) {
  const ctx = cvs.getContext("2d")!;
  // 畫布大小
  let width: number = 0;
  let height: number = 0;

  // 設置畫布大小與窗口同樣大
  const setSize = () => {
    // 獲取當前文檔內容區寬高
    width = document.documentElement.clientWidth;
    height = document.documentElement.clientHeight;
    // 類型保護只在上個做用域生效,因此這裏再寫一次
    if (cvs instanceof HTMLCanvasElement) {
      // 設置canvas的實際寬高
      cvs.width = width;
      cvs.height = height;
    }
  };
  window.onresize = setSize;
  setSize();

  // 小球數量
  const dotNum = 50;
  // 小球列表
  const dotList: Array<TDot> = [];
  // 隨機數
  const random: TRandom = (min, max) =>
    Math.floor(Math.random() * (max - min + 1) + min);
  // 隨機生成 1 和 -1
  const randomSign = () => [-1, 1][random(0, 1)];

  for (let i = 0; i < dotNum; i++) {
    dotList.push({
      // 隨機座標(4 是圓半徑)
      x: random(4, width - 4),
      y: random(4, height - 4),
      // 隨機加速度(randomSign 用來讓加速度有正反值,球就有不一樣的方向)
      xa: Math.random() * randomSign(),
      ya: Math.random() * randomSign(),
      // 圓點半徑
      radius: 4
    });
  }

  // 鼠標座標
  let point: Point = {
    x: -1,
    y: -1
  };

  // 鼠標座標實時獲取
  window.addEventListener("mousemove", ({ clientX, clientY }) => {
    point = {
      x: clientX,
      y: clientY
    };
  });
  // 移出窗口座標清除
  window.addEventListener("mouseout", () => {
    point = {
      x: -1,
      y: -1
    };
  });

  // 預設值距離值(平方值)
  const distSquare = 10000;
  // 將兩個小球進行連線(參數:兩點距離(平方值)、當前小球、下一個小球)
  const drawLine: TDrawLine = (dist, dot, nextDot) => {
    // 距離差值 與 預設距離的比例計算透明度,距離越近越不透明
    let op = (distSquare - dist) / distSquare;
    // 計算線條寬度
    const lineWidth = op / 2;
    ctx.lineWidth = lineWidth;
    // 設置線條顏色和透明度
    ctx.strokeStyle = `rgba(20, 112, 204,${op + 0.2})`;
    // 畫路徑
    ctx.beginPath();
    ctx.moveTo(dot.x, dot.y);
    ctx.lineTo(nextDot.x, nextDot.y);
    // 畫線
    ctx.stroke();
  };
  // 繪製函數
  const draw = () => {
    // 清空上次繪製
    ctx.clearRect(0, 0, width, height);

    dotList.forEach((dot, index) => {
      // 計算下一幀的座標
      dot.x += dot.xa;
      dot.y += dot.ya;

      // 計算邊界值
      const Xmin = dot.radius;
      const Xmax = width - dot.radius;
      const Ymin = dot.radius;
      const Ymax = height - dot.radius;

      // 判斷下一幀座標是否越界,越界則將加速度取反,小球就能夠在邊緣反彈。
      (dot.x >= Xmax || dot.x <= Xmin) && (dot.xa = -dot.xa);
      (dot.y >= Ymax || dot.y <= Ymin) && (dot.ya = -dot.ya);

      // 將越界座標矯正
      dot.x = dot.x >= Xmax ? Xmax : dot.x <= Xmin ? Xmin : dot.x;
      dot.y = dot.y >= Ymax ? Ymax : dot.y <= Ymin ? Ymin : dot.y;

      // 小球之間連線
      for (let i = index + 1; i < dotList.length; i++) {
        // dot後面的小球
        let nextDot = dotList[i];
        // 計算兩個小球的x 與 y 座標差值
        let x_dist = dot.x - nextDot.x;
        let y_dist = dot.y - nextDot.y;
        // 利用三角函數計算斜線長度,也就是兩小球距離
        let dist = x_dist * x_dist + y_dist * y_dist;
        // 兩點距離小於預設值則讓兩點連線
        if (dist < distSquare) {
          drawLine(dist, dot, nextDot);
        }
      }

      // 小球與鼠標之間連線(不爲-1表示鼠標在裏面)
      if (point.x !== -1) {
        // 計算鼠標與當前小球座標差值
        let x_dist = point.x - dot.x;
        let y_dist = point.y - dot.y;
        // 計算鼠標與當前小球直線距離
        let dist = x_dist * x_dist + y_dist * y_dist;
        // 小於預設值(能夠連線)
        if (dist < distSquare) {
          // 大於等於 預設值的一半 小於預設值(範圍是個外圓圈) 加速向鼠標
          if (dist >= distSquare / 2) {
            dot.x += 0.02 * x_dist;
            dot.y += 0.02 * y_dist;
          }
          drawLine(dist, dot, point);
        }
      }

      // 設置小球的顏色
      ctx.fillStyle = "#6cf";
      // 畫小球路徑
      ctx.beginPath();
      ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
      // 填充顏色
      ctx.fill();
    });
    requestAnimationFrame(draw);
  };

  // 動畫開始
  draw();
}
複製代碼

index.d.ts

interface Point {
  x: number;
  y: number;
}

interface TDot extends Point {
  radius: number;
  xa: number;
  ya: number;
}

type TRandom = (min: number, max: number) => number;

type TDrawLine = (dist: number, dot: TDot, nextDot: Point) => void;
複製代碼

結束。第一次寫分享,不足之處多多指正!

相關文章
相關標籤/搜索