🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

🧸 前端不是只會寫管理後台,我用 400 行程式碼畫了一个 LABUBU !

注意看,這個男人叫小何,別小看他,每天晚上 9 點 59 分他都準時打開泡泡瑪特小程序蹲守 LABUBU 抢购。就在剛才,螢幕時鐘倒計時又到 00:00:00 了,他立刻開始狂戳螢幕上的「立即購買」按鈕,切換「購買方式」反復刷新庫存,熟練的讓人心疼。

可是,現實卻從來沒有什麼“功夫不負有心人”,有的只是無數“黃牛”揮舞著自己的“科技”與小何同台競技。毫無意外,今天的小何依然沒有勝利,看着螢幕上的「已售罄」陷入了沉思 ……

拼尽全力也无法战胜吗?

空氣裡漂泊著手機螢幕反射的冷光,小何指尖的汗漬在「已售罄」三個字上洇出淡淡的印子。螢幕里 LABUBU 的笑臉還在倔強 —— 那隻頂著毛茸茸耳朵、圓眼圓腮的小家伙,本該是用來治愈生活的,此刻卻成了科技與欲望“厮殺”後,留給普通人的一道冷疤。

技術從來都該是溫柔的,當“黃牛”用它築起壁壘時,或許我該用同樣的東西,造一扇窗!

我是一名前端開發工程師,不是切圖仔,不是只會寫管理後台,今天勢必要奪回失去的一切!

是的,我畫了一個專屬於自己的 LABUBU !

👉 在線體驗:labubu.xiaohe.ink

✍️ 開始創作

LeaferJS 是一款好用的 Canvas 引擎,革新的開發體驗,可用於高效繪圖、UI 互動、圖形編輯。

Leafer Vue 是由 @FliPPeDround 基於 LeaferJS 創建的項目,可以使用 Vue 元件化輕鬆構建 Leafer 應用,具有以下特性:

  • 使用 Vue 構建 Leafer 應用,高效能
  • 生態統一,完全兼容 Leafer 插件
  • 由 TypeScript 編寫,提供強大的類型支持
  • 提供在線演練場,即開即用、暢享創作

現在,我們將使用 Leafer Vue 一起來完成這個作品!

一半茶葉蛋

首先是 LABUBU 的腦袋,看起來有點像被切開的茶葉蛋,可以用兩段二次貝塞爾曲線來繪製一個非對稱橢圓表示。

我們先編寫 createBezierEllipsePath 工具方法,用於生成更自然流暢的橢圓路徑:

import { PathCreator } from "leafer-ui";

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

/**
 * 以控制點 cp 為中心反射生成點 p 關於它的對稱點
 */
function reflect(p: Point, cp: Point) {
  return {
    x: p.x + (p.x - cp.x),
    y: p.y + (p.y - cp.y)
  };
}

/**
 * 創建非對稱橢圓路徑
 */
export function createBezierEllipsePath(p1: Point, p2: Point, ox: number, oy: number) {
  const cp1 = { x: p1.x + ox, y: p1.y + oy };
  const cp2 = { x: p2.x - ox, y: p2.y + oy };

  // 通過反射生成另外兩個控制點
  const cp3 = reflect(p2, cp2);
  const cp4 = reflect(p1, cp1);

  return new PathCreator()
    .moveTo(p1.x, p1.y)
    // 第 1 段貝塞爾曲線
    .bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y)
    // 第 2 段貝塞爾曲線
    .bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p1.x, p1.y)
    .closePath()
    .path;
}

然後調用 createBezierEllipsePath 創建頭部和臉部的路徑:

const headPath = createBezierEllipsePath(
  { x: 40, y: 240 },
  { x: 260, y: 240 },
  28,
  -120
);

const facePath = createBezierEllipsePath(
  { x: 60, y: 260 },
  { x: 240, y: 260 },
  -10,
  80
);

使用 Path 標籤傳入路徑,再加上填充色和描邊:

<!-- 頭 -->
<Path
  :path="headPath"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
/>

<!-- 臉 -->
<Path
  :path="facePath"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="3"
/>

✨ 腦袋部分完成啦!

腦袋部分

一個魔丸

畫好了腦袋,現在開始畫五官。光看五官 LABUBU 跟“魔丸”哪吒是不是有點神似?哪吒和泡泡瑪特甚至推出過聯名款!

眼睛畫起來很簡單,直接使用 Ellipse 標籤繪製幾個橢圓組合起來就好,至於眉毛就用 Line 標籤畫一條曲線吧 ~

<!-- 左眼白 -->
<Ellipse
  :x="93"
  :y="228"
  :width="40"
  :height="60"
  fill="#f9f9f9"
  stroke="#000000"
  :stroke-width="2"
/>

<!-- 左上眼睑 -->
<Ellipse
  :x="96"
  :y="206"
  :width="44"
  :height="26"
  :rotation="10"
  :start-angle="20"
  :end-angle="154"
  fill="#ffd9d0"
/>

<!-- 左眉毛 -->
<Line
  :points="[96, 226, 104, 233, 124, 235, 134, 232]"
  curve
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
/>

<!-- 左眼球 -->
<Ellipse
  :x="100"
  :y="242"
  :width="28"
  :height="45"
  fill="#000000"
/>

<!-- 左眼光 -->
<Ellipse
  :x="111"
  :y="245"
  :width="6"
  :height="10"
  fill="#ffffff"
/>

<!-- 右眼白 -->
<Ellipse
  :x="165"
  :y="228"
  :width="40"
  :height="60"
  fill="#f9f9f9"
  stroke="#000000"
  :stroke-width="2"
/>

<!-- 右上眼睑 -->
<Ellipse
  :x="158"
  :y="214"
  :width="44"
  :height="26"
  :rotation="-10"
  :start-angle="24"
  :end-angle="158"
  fill="#ffd9d0"
/>

<!-- 右眉毛 -->
<Line
  :points="[164, 232, 176, 236, 194, 233, 202, 226]"
  curve
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
/>

<!-- 右眼球 -->
<Ellipse
  :x="171"
  :y="242"
  :width="28"
  :height="45"
  fill="#000000"
/>

<!-- 右眼光 -->
<Ellipse
  :x="181"
  :y="245"
  :width="6"
  :height="10"
  fill="#ffffff"
/>

鼻子也是一個非對稱橢圓,可以用之前編寫的 createBezierEllipsePath 創建一個小小的橢圓:

const nosePath = createBezierEllipsePath(
  { x: 141, y: 275 },
  { x: 157, y: 275 },
  2,
  9
);
<!-- 鼻子 -->
<Path
  :path="nosePath"
  fill="#ff0154"
  stroke="#000000"
  :stroke-width="2"
/>

嘴巴是一條 0.76 曲率的曲線,使用 Path 標籤的 curve 參數可以輕鬆實現。

但是牙齒畫起來就比較麻煩了,因為要緊密貼合嘴巴曲線,所以我們需要編寫一個方法將嘴巴的曲率轉換為三次貝塞爾曲線,再根據傳入牙齒的數量和大小沿曲線切線方向排布並生成對應的路徑數組。

方法的具體實現如下:

// 嘴巴曲線
const mouthPoints = [76, 266, 150, 304, 224, 266];
// 嘴巴曲率
const mouthCurve = 0.76;

/**
 * 創建牙齒路徑
 */
function createTeethPaths(
  count: number,
  toothWidth: number,
  toothHeight: number,
  curve: number
) {
  const p1 = { x: mouthPoints[0], y: mouthPoints[1] };
  const c0 = { x: mouthPoints[2], y: mouthPoints[3] };
  const p2 = { x: mouthPoints[4], y: mouthPoints[5] };

  function lerp(a: number, b: number, t: number) {
    return a + (b - a) * t;
  }

  // 貝塞爾曲線中間控制點
  const c1 = {
    x: lerp(p1.x, c0.x, 0.5) - curve * 20,
    y: lerp(p1.y, c0.y, 0.5) + curve * 43
  };
  const c2 = {
    x: lerp(c0.x, p2.x, 0.5) + curve * 20,
    y: lerp(c0.y, p2.y, 0.5) + curve * 43
  };

  /** 
   * 三次貝塞爾計算 
   */
  function cubic(t: number): [number, number] {
    return [
      (1 - t) ** 3 * p1.x + 3 * (1 - t) ** 2 * t * c1.x + 3 * (1 - t) * t ** 2 * c2.x + t ** 3 * p2.x,
      (1 - t) ** 3 * p1.y + 3 * (1 - t) ** 2 * t * c1.y + 3 * (1 - t) * t ** 2 * c2.y + t ** 3 * p2.y
    ];
  }

  /** 
   * 貝塞爾切線 
   */
  function derivative(t: number): [number, number] {
    return [
      3 * (1 - t) ** 2 * (c1.x - p1.x) + 6 * (1 - t) * t * (c2.x - c1.x) + 3 * t ** 2 * (p2.x - c2.x),
      3 * (1 - t) ** 2 * (c1.y - p1.y) + 6 * (1 - t) * t * (c2.y - c1.y) + 3 * t ** 2 * (p2.y - c2.y)
    ];
  }

  const value: number[][] = [];

  for (let i = 0; i < count; i += 1) {
    const t = i / (count - 1);

    const [cx, cy] = cubic(t);
    const [dx, dy] = derivative(t);

    const length = Math.sqrt(dx * dx + dy * dy);

    // 法向量
    const nx = -dy / length;
    const ny = dx / length;

    const halfWidth = toothWidth / 2;

    const x1 = cx - halfWidth * dx / length;
    const y1 = cy - halfWidth * dy / length;
    const x2 = cx + halfWidth * dx / length;
    const y2 = cy + halfWidth * dy / length;

    const xt = cx + toothHeight * nx;
    const yt = cy + toothHeight * ny;

    const path = new PathCreator()
      .moveTo(x1, y1)
      .quadraticCurveTo(xt, yt, x2, y2)
      .closePath()
      .path;

    value.push(path);
  }

  return value;
}

const teethPaths = createTeethPaths(11, 16, 18, mouthCurve);

然後使用 v-for 循環生成牙齒:

<!-- 嘴巴 -->
<Line
  :points="mouthPoints"
  :curve="mouthCurve"
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
/>

<!-- 牙齒 -->
<Path
  v-for="(item, index) in teethPaths"
  :key="index"
  :path="item"
  fill="#ffffff"
  stroke="#000000"
  :stroke-width="2"
/>

🥳 我們完成了整個作品中最困難的部分!

嘴巴部分

滑稽兔耳朵

LABUBU 的耳朵跟滑稽兔很像,畫起來也比較容易,用 Ellipse 標籤繪製兩個縱向的扁橢圓:

<!-- 左耳 -->
<Ellipse
  :x="74"
  :y="56"
  :width="65"
  :height="150"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
/>

<!-- 右耳 -->
<Ellipse
  :x="156"
  :y="56"
  :width="65"
  :height="150"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
/>

耳朵部分

再用兩個 Ellipse 標籤繪製不同顏色的小橢圓表示內耳和耳蝸:

<!-- 左內耳 -->
<Ellipse
  :x="82"
  :y="72"
  :width="50"
  :height="120"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="2"
/>

<!-- 左耳蝸 -->
<Ellipse
  :x="95"
  :y="118"
  :width="26"
  :height="60"
  fill="#ffbbbf"
  stroke="#000000"
  :stroke-width="2"
/>

<!-- 右內耳 -->
<Ellipse
  :x="164"
  :y="72"
  :width="50"
  :height="120"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="2"
/>

<!-- 右耳蝸 -->
<Ellipse
  :x="176"
  :y="118"
  :width="26"
  :height="60"
  fill="#ffbbbf"
  stroke="#000000"
  :stroke-width="2"
/>

🐰 整個頭部都完成啦!

頭部完成

像個布娃娃

身體部分需要花一些心思,我們這裡使用兩段二次貝塞爾曲線(手臂)和兩段三次貝塞爾曲線(腿)組合完成:

const bodyPath = new PathCreator()
  .moveTo(84, 316)
  .quadraticCurveTo(40, 374, 90, 368)
  .bezierCurveTo(74, 460, 140, 440, 147, 430)
  .bezierCurveTo(154, 444, 224, 454, 204, 368)
  .quadraticCurveTo(254, 374, 210, 316)
  .closePath()
  .path;

再加上填充色和描邊就形成了身體:

<!-- 身體 -->
<Path
  :path="bodyPath"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
/>

🐻 是不是很像一個布娃娃?可愛捏!

布娃娃

加上小手和小腳

終於到了作品的最後一部分,使用多段二次貝塞爾曲線組合繪製出 LABUBU 的小手和小腳:

const leftHandPath = new PathCreator()
  .moveTo(68, 352)
  .quadraticCurveTo(48, 348, 59, 360)
  .quadraticCurveTo(42, 372, 58, 370)
  .quadraticCurveTo(50, 386, 66, 372)
  .quadraticCurveTo(68, 392, 76, 366)
  .closePath()
  .path;

const rightHandPath = new PathCreator()
  .moveTo(226, 352)
  .quadraticCurveTo(246, 348, 235, 360)
  .quadraticCurveTo(252, 372, 236, 370)
  .quadraticCurveTo(244, 386, 228, 372)
  .quadraticCurveTo(226, 392, 218, 366)
  .closePath()
  .path;

const leftFootPath = new PathCreator()
  .moveTo(104, 430)
  .quadraticCurveTo(103, 456, 115, 444)
  .quadraticCurveTo(122, 456, 128, 444)
  .quadraticCurveTo(144, 456, 140, 430)
  .closePath()
  .path;

const rightFootPath = new PathCreator()
  .moveTo(191, 430)
  .quadraticCurveTo(192, 456, 180, 444)
  .quadraticCurveTo(173, 456, 167, 444)
  .quadraticCurveTo(151, 456, 155, 430)
  .closePath()
  .path;
<!-- 左手 -->
<Path
  :path="leftHandPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
/>

<!-- 右手 -->
<Path
  :path="rightHandPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
/>

<!-- 左腳 -->
<Path
  :path="leftFootPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
/>

<!-- 右腳 -->
<Path
  :path="rightFootPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
/>

🎉 LABUBU 誕生!

LABUBU

🖥️ 原碼

項目的完整程式碼可以在 leafer-labubu 倉庫中查看。

贈人玫瑰,手留餘香,如果對你有幫助可以給我一個 ⭐️ 鼓勵,這將是我繼續前進的動力,謝謝大家 🙏!

🍬 感謝

項目靈感及圖形創意來源於 LABUBU 簡筆畫教程 - Thomas

🍵 寫在最後

我是 xiaohe0601,熱愛程式碼,目前專注於 Web 前端領域。

歡迎關注我的微信公众号「小何不會寫程式碼」,我會不定期分享一些開發心得、最佳實踐以及技術探索等內容,希望能夠幫到你!


原文出處:https://juejin.cn/post/7571846248719581184


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝13   💬4   ❤️5
424
🥈
我愛JS
📝2   💬3   ❤️3
66
🥉
酷豪
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付