# 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,
});
}
}
};
```