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