---
# System prepended metadata

title: Avatar Creation 角色系統

---

# Avatar Creation 角色系統

更新時間：2022/12/19
編輯人員：Louise
相關技術：`PixiJS V6`, `pixi-spine`, `@pixi-spine/runtime4.0`, `Spine`

GitHub：https://github.com/BKHoleCreative/potato-avatar

## 安裝套件與採用版本

### PixiJS：
基於 Javascript 的 2D 繪圖引擎，用於渲染 Spine 建立的角色。
- 安裝版本：`npm i pixi.js@6.3.0`
- 文件：https://pixijs.download/v6.1.1/docs/index.html

### pixi-spine：
支援 Spine 檔案操作的 PixiJS 套件。
- 安裝版本：`npm i pixi-spine@3.0.13`
- 文件：https://github.com/pixijs/spine

### @pixi-spine/runtime：
載入 Spine 的相關功能
- 安裝版本：`npm i @pixi-spine/runtime-4.0`
- 文件：http://zh.esotericsoftware.com/spine-api-reference

## import 套件
```javascript
import * as PIXI from "pixi.js";
import * as spine from "pixi-spine";
import { Skin } from "@pixi-spine/runtime-4.0";
```
## 角色製作

用 Spine 製作完角色與動畫後會輸出三個檔案：json、atlas、png。這三個檔案會供 PixiJS 讀取骨架、皮膚等資料。這三個檔案要放到專案資料夾時**路徑和檔名必須一樣**。

Spine 繁體中文版將 Skin 翻譯成「外觀」，但是 Spine 官網採用的翻譯為「皮膚」，為了方便對照官網資料，底下 Skin 會用採用官方定義的「皮膚」來稱呼。

備註：
- Spine 的 json 輸出版本：3.8
- Spine 角色詳細製作說明請參考 [Spine 官方文件](http://zh.esotericsoftware.com/)

## 資料夾說明

- public
    - js：引用外部的 js 檔案
    - spineData：由 Spine 輸出的每包 json、atlas、png，同一包的三個檔案一定要放在相同路徑。
    - 路徑格式與參數：spineData/gender/category/skinGroup/gender.json、atlas、png

    ![](https://i.imgur.com/rEkR69h.png)
    - background.png：產生 gif 圖時使用的背景圖

    :::warning 
    透過 PixiJS 引用的圖檔必須放在 Public 裡 
    :::
    
- src
    - assets
        - images：相對路徑的圖檔
        - scss
    - components
        - LoadingGif.vue：等待生成 Gif 時的讀取畫面
        - Spine.vue：主要處理 PixiJS 和讀取 Spine 的組件


## 自定義的資料格式與變數說明

由於 Spine 的 json 檔讀取後會有許多不同的資料，為了方便存取使用，我們自定義了 spineDataCustom、skeletonCustom 資料格式來管理。

- spineDataCustom：對應到 json 檔內的 spineData
    ```javascript =
    spineDataCustom: {
        Body: Object,
        Clothes: Object,
        Hair: Object,
        Pants: Object,
        Shoes: Object
    }
    ```
- skeletonCustom：對應到 spineData 內的 skeleton
    ```javascript =
    skeletonCustom: {
        Body: Object,
        Clothes: Object,
        Hair: Object,
        Pants: Object,
        Shoes: Object
    }
    ```
- skinDataCustom：Avatar 當下套用的皮膚資料
    - 皮膚群組與皮膚的名稱會對應 Spine 的資料結構命名
    - 皮膚群組/皮膚名稱 = skinGroup/skinName =`G_Body1/G_Body0`
    - 所以如果套用下圖第一個資料夾下的第一筆衣服資料`G_Body1/G_Body0`，就會將此皮膚名稱存入 `skinDataCustom/girl` 的 `Body` 內

    ![](https://i.imgur.com/bFz0kPQ.png)

    ```javascript =
    skinDataCustom: {
        boy: {
            Body: "皮膚群組/皮膚名稱",    # G_Body1/G_Body0
            Clothes: "皮膚群組/皮膚名稱",
            Hair: "皮膚群組/皮膚名稱",
            Pants: "皮膚群組/皮膚名稱",
            Shoes: "皮膚群組/皮膚名稱"
        }
        girl: {
            Body: "皮膚群組/皮膚名稱",
            Clothes: "皮膚群組/皮膚名稱",
            Hair: "皮膚群組/皮膚名稱",
            Pants: "皮膚群組/皮膚名稱",
            Shoes: "皮膚群組/皮膚名稱"
        }
    }
    ```
- currentSelected：當前選取的皮膚資料，會拿來跟 SkinDataCustom 比較，再依此判斷怎麼抽換。
    ```javascript =
    currentSelected: {
        category: "目前類別",
        gender: "目前性別",
        skinGroup: {
            boy: { Body: "目前的皮膚群組", ...},
            girl: { Body: "目前的皮膚群組", ...}
        }
    }
    ```
- categoryList：所有類別(Hair、Clothes、Shoes ...)的清單
    ```javascript =
    categoryList: {
      {
        name: "髮型",
        en: "Hair",
        imgUrl: new URL("../assets/images/hair.svg", import.meta.url).href,
      },
      // ...其他類別
    }
    ```
- accessoryImages：所有配件與膚色的清單
    - 皮膚群組與皮膚的名稱會對應 Spine 的資料結構命名
    - 皮膚群組/名稱 = skinGroup/name =`G_Body1/G_Body0`

    ![](https://i.imgur.com/bFz0kPQ.png)
    ```javascript =
     accessoryImages: {
      boy: {
        Body: [
          {
            id: 1,
            skinGroup: "B_Body1",
            name: "B_Body0",
            color: "#F2AC99",
          },
          // { ...其他膚色 }
        ],
        Hair: [
          {
            id: 1,
            skinGroup: "B_Hair1",
            name: "B_Hair0",
            imgUrl: new URL(
              "../assets/images/boy-accessories/B_Hair0.png",
              import.meta.url
            ).href,
          },
          // { ...其他髮型 }
        ],
        // [ ...其他類別 ] 
      },
      // girl: { ... } 
     }
    ```
- genderList：性別的資料清單
    ```javascript =
    genderList: {
      {
        gender: "girl",
        imgUrl: new URL("../assets/images/girl-icon.svg", import.meta.url).href,
      },
      {
        gender: "boy",
        imgUrl: new URL("../assets/images/boy-icon.svg", import.meta.url).href,
      }
    }
    ```
## UI 介面與 function 的對應圖
![](https://i.imgur.com/RnpQAO7.png)



## HTML 結構說明與對應功能

### 展示指定類別（Hair、Clothes、Shoes ...）的選單
- Line 709，用迴圈渲染 `categoryList` 內的資料
- Line 711，綁定 click 事件，點擊類別圖示更新 `currentSelected.category` ，依照「當前類別」重新渲染配件圖
```htmlmixed=707
<ul>
  <li
  v-for="(category, idx) in categoryList"
  :key="idx"
  @click="currentSelected.category = category.en"
  >
    <div
    class="icon item-center"
    :class="{ active: currentSelected.category === category.en }"
    >
      <img :src="category.imgUrl" :alt="category.name" />
    </div>
    {{ category.name }}
  </li>
</ul>
```

### 展示當前類別的所有配件圖
- Line 726，用迴圈渲染 `accessoryImages` 內「當前性別」的「當前類別」資料
- 在 `li`、`img` 定義 `data-category`、`data-skingroup` 做為 `changeAttachment()` 要抓取的 event 事件參數
- Line 741，綁定 click 事件執行 `changeAttachment()` 切換配件
```htmlmixed=722
<div class="accessory-select-block block-style">
  <h2>選擇{{ categoryCh }}</h2>
  <ul>
    <li
      v-for="accessory in accessoryImages[currentSelected.gender][
        currentSelected.category
      ]"
      :key="accessory.id"
    >
      <div
        class="accessory-image item-center"
        :class="{
          active:
            skinDataCustom[currentSelected.gender][
              currentSelected.category
            ] === `${accessory.skinGroup}/${accessory.name}`,
        }"
        :data-category="currentSelected.category"
        :data-skingroup="accessory.skinGroup"
        @click="changeAttachment($event, accessory.name)"
      >
        <img
          :alt="accessory.name"
          :data-category="currentSelected.category"
          :data-skingroup="accessory.skinGroup"
        />
      </div>
    </li>
  </ul>
</div>
```
### 性別選擇圖示
- Line 764，用迴圈渲染 `genderList` 內的資料
- Line 766，綁定 click 事件執行 `init()` 切換性別
```htmlmixed=760
<ul class="sex-selector">
  <li
    class="item-center"
    :class="{ active: currentSelected.gender === gender.gender }"
    v-for="(gender, idx) in genderList"
    :key="idx"
    @click="init(gender.gender)"
  >
    <img :src="gender.imgUrl" :alt="gender.gender" />
  </li>
</ul>
```
### 膚色選擇圖示
- Line 773，迴圈渲染 `accessoryImages` 內「當前性別」的「Body」資料
- 在 `li` 定義 `data-category`、`data-skingroup` 做為 `changeAttachment()` 要抓取的 event 事件參數
- Line 783，綁定 click 事件執行 `changeAttachment()` 切換膚色
```htmlmixed=771
<ul class="skin-selector d-flex">
  <li
    v-for="(body, idx) in accessoryImages[currentSelected.gender].Body"
    :key="idx"
    :style="{ backgroundColor: body.color }"
    :class="{
      active:
        skinDataCustom[currentSelected.gender].Body ===
        `${body.skinGroup}/${body.name}`,
    }"
    data-category="Body"
    :data-skinGroup="body.skinGroup"
    @click="changeAttachment($event, body.name)"
  ></li>
</ul>
```

## 功能說明與流程圖
![](https://i.imgur.com/JG4XR5O.png)


- 初始化 **onMounted**
    1. 在 HTML 新增 canvas，來渲染 PIXI 畫布
        ```htmlmixed
        <canvas id="spine-character"></canvas>
        ```
    2. 畫面初始化
        ```javascript
        onMounted(() => {
          // 初始化 PIXI 畫布
          const app = new PIXI.Application({
            view: document.querySelector("#spine-character"),
            width: 500,
            height: 500,
            backgroundAlpha: 0,
            preserveDrawingBuffer: true,
          });
          // 自動縮放
          app.renderer.autoResize = true;
          // 開啟自動解析atlas
          app.loader.use(spine.SpineParser.use);

          // 清除快取
          PIXI.utils.clearTextureCache();

          // 讀取全身 Spine 檔案
          init("girl");
          isInit = false;

          // 啟動 PIXI 渲染    
          app.renderer.render(app.stage);
          // 設定 PIXI 畫布在 canvas 中的位置
          app.stage.position.set(app.renderer.width * 0.32, app.renderer.height * 0.1);
        });
        ```

- **init** (gender)
    讀取當前性別各類別的 Spine json 檔。
    | name   | type     | description    |
    | ------ | -------- | -------------- |
    | <font color="#c00">`gender`</font> | <font color="#888">String</font> | <font color="#888">要讀取的性別</font> |

    ```javascript =
    const init = (gender) => {
      // 如果傳入的 gender 和當前性別不同，便更新當前性別並繼續執行以下動作
      if ((currentSelected.value.gender === gender && isInit == true) || 
           currentSelected.value.gender !== gender ){
        currentSelected.value.gender = gender;
          
        // 將所有類別定義成陣列，以迴圈的方式來執行 .loadSpineData() 讀取各自的 json 檔
        const categoryList = ["Body", "Hair", "Shoes", "Pants", "Clothes"];
        categoryList.forEach((category, idx) => {
          loadSpineData({
            gender: gender,
            category: category,
            skinGroup: currentSelected.value.skinGroup[gender][category],
            zIndex: idx,
          });
        });
      }
    };
    ```
- **loadSpineData** (gender, skin, skinGroup, zIndex)
    讀取 Spine json 檔以建立各皮膚的 spineData、skeleton，並將 Spine 角色新增到 Pixi 畫布上。
    | name   | type     | description    |
    | ------ | -------- | -------------- |
    | <font color="#c00">`gender`</font> | <font color="#888">String</font> | <font color="#888">要讀取的性別</font> |
    | <font color="#c00">`skin`</font> | <font color="#888">String</font> | <font color="#888">要讀取的皮膚</font> |
    | <font color="#c00">`skinGroup`</font> | <font color="#888">String</font> | <font color="#888">皮膚所在的群組</font> |
    | <font color="#c00">`zIndex`</font> | <font color="#888">Number</font> | <font color="#888">app stage的圖層順序</font> |
    
    ```javascript =
    const loadSpineData = (props) => {
    const { gender, category, skinGroup, zIndex } = props;

    // 建立 loader 讀取 Spine json 檔
    const loader = new PIXI.Loader();
    // loader.add("類別名稱", json 檔路徑)
    // loader.load((loader, resources)=>{})
    loader
      .add(category,`spineData/${gender}/${category}/${skinGroup}/${gender}.json`)
      .load((loader, res) => {
        // 抓取各類別的 spineData、新增到自定義的 spineDataCustom 的物件裡
        // res[category] = 我們在 loader.add() 定義的類別名稱
        spineDataCustom[category] = res[category].spineData;
        const skinData = new spine.Spine(spineDataCustom[category]);
        skinData.name = category;

        // 判斷 app.stage 是否存在同樣名稱的圖層
        let child = null;
        child = app.stage.getChildByName(category);
        if (child) {
          child.destroy({ texture: true, baseTexture: true });
          PIXI.utils.clearTextureCache();
        }

        // 建立圖層並新增到 app stage 上
        const spineObj = app.stage.addChild(skinData);
        // 設定大小
        spineObj.scale.set(0.5);
        // 設定順序
        spineObj.zIndex = zIndex;
        // 新增各類別的 skeleton 到自定義的 skeletonCustom 的物件裡
        skeletonCustom[category] = skinData.skeleton;
        // 依照 zIndex 重新排序圖層
        app.stage.sortChildren();
        // 開啟 spine 動畫
        app.stage.children.forEach((spine) => {
          spine.state.setAnimation(0, "breath", true);
        });

        // 為每個類別設定要顯示的皮膚
        setSkin(category);
      });
     };
    ```
- **setSkin** (category)
    為每個類別設定要顯示的皮膚
    | name   | type     | description    |
    | ------ | -------- | -------------- |
    | <font color="#c00">`category`</font> | <font color="#888">String</font> | <font color="#888">要設定皮膚的類別</font> |

    ```javascript =
    const setSkin = (category) => {
      // 定義一個新皮膚
      const newSkin = new Skin("skin");
      // 在類別的 spineData 裡尋找「當前選取的皮膚名稱」皮膚，並新增到newSkin上
      // Skin.addSkin(skin)
      // spineData.findSkin(SkinName)
      newSkin.addSkin(
        spineDataCustom[category].findSkin(
          skinDataCustom.value[currentSelected.value.gender][category]
        )
      );
      // 在類別的骨架設定新的皮膚
      // skeleton.setSkin(Skin)
      skeletonCustom[category].setSkin(newSkin);
    };
    ```
- **changeAttachment** (event, skinName)
    為每個類別設定要顯示的皮膚
    | name   | type     | description    |
    | ------ | -------- | -------------- |
    | <font color="#c00">`event`</font> |  | <font color="#888">滑鼠事件</font> |
    | <font color="#c00">`skinName`</font> | String | <font color="#888">選取到的皮膚名稱</font> |

    ```javascript =
    const changeAttachment = (e, name) => {
      // 抓取類別、皮膚所在的群組
      const category = e.target.dataset.category;
      const skinGroup = e.target.dataset.skingroup;
      // 組成 skinDataCustom 的皮膚名稱格式
      const skinName = `${skinGroup}/${name}`;
      const gender = currentSelected.value.gender;
        
      // 切換各類別對應的圖層順序
      let zIndex = 0;
      switch (category) {
        case "Hair":
          zIndex = 1;
          break;
        case "Shoes":
          zIndex = 2;
          break;
        case "Pants":
          zIndex = 3;
          break;
        case "Clothes":
          zIndex = 4;
          break;
        default:
          zIndex = 0;
      }
        
      if (skinDataCustom.value[gender][category] !== skinName) {
        // 更新 skinDataCustom 中該類別的 skinName
        skinDataCustom.value[gender][category] = skinName;
        // 判斷1：當前皮膚與前一個皮膚在同一個 skinGroup
        if (skinGroup === currentSelected.value.skinGroup[gender][category]) {
          setSkin(category);
        // 判斷2：當前皮膚與前一個皮膚在不同 skinGroup    
        } else if (skinGroup !== currentSelected.value.skinGroup[gender][category]) {
          currentSelected.value.skinGroup[gender][category] = skinGroup;
          loadSpineData({
            gender: gender,
            category: category,
            skinGroup: skinGroup,
            zIndex: zIndex,
          });
        }
      }
    };
    ```