Android 平臺上實現DragonBones換裝功能

語言: CN / TW / HK

theme: fancy

前言

最近在預研一款換裝的小遊戲,通過在積分樂園中兌換服裝,就可以在不同場景中展示穿上新服裝的角色。對於這類有主題形象的動畫,自然就想到了骨骼動畫,通過網格自由變形和蒙皮技術就能在視覺上呈現所需要的動畫效果,並且骨骼動畫也支援面板替換,或者插槽的圖片替換,對於換裝的需求比較友好。因此決定使用骨骼動畫來實現換裝小遊戲的Demo,以下就是在Android平臺上實現DragonBones換裝的過程。

2.gif

技術選型

對於DragonBones在Android端的渲染顯示,有多個方案可以選擇,例如:白鷺引擎或者Cocos2d遊戲引擎。最終選擇使用korge來進行渲染,為什麼拋棄Cocos2d這個廣泛使用的遊戲引擎來渲染呢?主要理由是:

  1. Cocos2d 遊戲引擎載入比較耗時,其首次載入時間無法接受;
  2. Cocos2d 編譯出來的底層依賴需要單獨裁剪,裁剪後的libcocos.so依然較大;
  3. Cocos2d 對於遊戲動畫的渲染,其渲染的載體是Activity,也就是編譯出來的CocosActivity,這個是無法滿足業務需要的。因此需要自定義遊戲容器,並且需要修改動畫載入的容器載體和載入路徑。簡單點來說,可以從任意路徑來載入遊戲資源(例如網路或者本地,不僅僅是assets目錄),並且可以在自定義View中進行渲染。解決思路可以參考:https://juejin.cn/post/6984366261610217502

最終,還是在官方的Github上發現這條Issue,從而找到了Android上渲染DragonBones的方式。Korge的介紹是這樣的:

Modern Multiplatform Game Engine for Kotlin.

Korge的基本用法

Korge的官網地址:https://korge.org/

Korge的Github地址:https://github.com/korlibs/korge

1)建立 DragonBones Scene

class DisplayChangeImgScene : BaseDbScene() { companion object {        private const val SKE_JSON = "mecha_1004d_show/mecha_1004d_show_ske.json"        private const val TEX_JSON = "mecha_1004d_show/mecha_1004d_show_tex.json"        private const val TEX_PNG = "mecha_1004d_show/mecha_1004d_show_tex.png"   } ​    private val factory = KorgeDbFactory() ​    override suspend fun Container.createSceneArmatureDisplay(): KorgeDbArmatureDisplay {        val skeDeferred = asyncImmediately { res[SKE_JSON].readString() }        val texDeferred = asyncImmediately { res[TEX_JSON].readString() }        val imgDeferred = asyncImmediately { res[TEX_PNG].readBitmap().mipmaps() } ​        val skeJsonData = skeDeferred.await()        val texJsonData = texDeferred.await()        factory.parseDragonBonesData(Json.parse(skeJsonData)!!)        factory.parseTextureAtlasData(Json.parse(texJsonData)!!, imgDeferred.await()) ​        val armatureDisplay = factory.buildArmatureDisplay("mecha_1004d")!!.position(500, 700)        armatureDisplay.animation.play("idle") ​        return armatureDisplay   } }

2)使用KorgeAndroidView載入 Scene Module

class MainActivity : AppCompatActivity() { ​    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } ​    private val slotDisplayModule by sceneModule<DisplayChangeImgScene>() ​    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        setContentView(binding.root)                binding.root.addView(KorgeAndroidView(this).apply {            loadModule(slotDisplayModule)       })   } }

3)sceneModule 函式

@MainThread inline fun <reified DS : BaseDbScene> Activity.sceneModule(    windowWidth: Int = resources.displayMetrics.widthPixels,    windowHeight: Int = resources.displayMetrics.heightPixels ): Lazy<Module> {    return SceneModuleLazy(DS::class, windowWidth, windowHeight) } ​ class SceneModuleLazy<DS : BaseDbScene>(    private val dbSceneClass: KClass<DS>,    private val width: Int,    private val height: Int ) : Lazy<Module> {    private var cached: Module? = null ​    override val value: Module        get() {            return cached ?: object : Module() {                override val mainScene = dbSceneClass ​                override suspend fun AsyncInjector.configure() {                    mapPrototype(dbSceneClass) {                        val sceneInstance = Class.forName(dbSceneClass.qualifiedName!!).newInstance()                        sceneInstance as DS                   }               } ​                override val fullscreen = true ​                override val size: SizeInt                    get() = SizeInt(width, height) ​                override val windowSize: SizeInt                    get() = SizeInt(width, height)           }       } ​    override fun isInitialized(): Boolean = cached != null }

上面就是最簡單的Demo,通過載入DragonBones的配置資料即可顯示骨骼動畫。

實現換裝的多種實現

靜態換裝 vs 動態換裝

靜態換裝

如果換裝的素材是固定的,可以預先放置在插槽裡,通過切換插槽的displayIndex實現換裝。

  1. 在骨骼動畫設計時,每個slot可對應多個display,例如:

    {  "name": "weapon_hand_l",  "display": [   {      "name": "weapon_1004_l",      "transform": {        "x": 91.22,        "y": -30.21     }   },   {      "name": "weapon_1004b_l",      "transform": {        "x": 122.94,        "y": -44.14     }   },   {      "name": "weapon_1004c_l",      "transform": {        "x": 130.95,        "y": -56.95     }   },   {      "name": "weapon_1004d_l",      "transform": {        "x": 134.67,        "y": -55.25     }   },   {      "name": "weapon_1004e_l",      "transform": {        "x": 155.62,        "y": -59.2     }   } ] }

  2. 在程式碼中,可直接切換display進行換裝,即:

    private var leftWeaponIndex = 0    private val leftDisplayList = listOf(        "weapon_1004_l", "weapon_1004b_l", "weapon_1004c_l", "weapon_1004d_l", "weapon_1004e_l"   ) ​    override suspend fun Container.createSceneArmatureDisplay(): KorgeDbArmatureDisplay {        val skeDeferred = asyncImmediately { Json.parse(res["mecha_1004d_show/mecha_1004d_show_ske.json"].readString())!! }        val texDeferred = asyncImmediately { res["mecha_1004d_show/mecha_1004d_show_tex.json"].readString() }        val imgDeferred = asyncImmediately { res["mecha_1004d_show/mecha_1004d_show_tex.png"].readBitmap().mipmaps() } ​        factory.parseDragonBonesData(skeDeferred.await())        factory.parseTextureAtlasData(Json.parse(texDeferred.await())!!, imgDeferred.await()) ​        val armatureDisplay = factory.buildArmatureDisplay("mecha_1004d")!!.position(500, 700)        armatureDisplay.animation.play("idle") ​        val slot = armatureDisplay.armature.getSlot("weapon_hand_l")!!        mouse {            upAnywhere {                leftWeaponIndex++;                leftWeaponIndex %= leftDisplayList.size ​                factory.replaceSlotDisplay(                    dragonBonesName = "mecha_1004d_show",                    armatureName = "mecha_1004d",                    slotName = "weapon_hand_l",                    displayName = leftDisplayList[leftWeaponIndex],                    slot = slot               )           }       } ​        return armatureDisplay   }

#### 動態換裝

如果換裝的素材是不固定的,需要動態獲取資源,或者通過一張外部圖片來實現換裝效果,可以通過修改slot的顯示紋理即可實現。

```
// 換裝原理是:通過factory.parseTextureAtlasData來解析紋理資料,紋理為外部圖片,紋理配置為Mock資料
private fun changeSlotDisplay(slot: Slot, replaceBitmap: Bitmap) {
    // 使用 HashCode 來作為 骨架名稱 和 骨骼名稱
    val replaceArmatureName = replaceBitmap.hashCode().toString()
    // 需要替換的插槽所包含的顯示物件
    val replaceDisplayName = slot._displayFrames.first { it.rawDisplayData != null }.rawDisplayData!!.name
​
    // 通過factory解析紋理資料
    val mockTexModel = mockTexModel(replaceArmatureName, replaceDisplayName, replaceBitmap.width, replaceBitmap.height)
    val textureAtlasData = Json.parse(gson.toJson(mockTexModel))!!
    factory.parseTextureAtlasData(textureAtlasData, replaceBitmap.mipmaps())
​
    // 替換 Display 的紋理,替換的圖片和原圖大小、位置一致
    val replaceTextureData = getReplaceDisplayTextureData(replaceArmatureName, replaceDisplayName)
    slot.replaceTextureData(replaceTextureData)
​
    slot._displayFrame?.displayData?.transform?.let {
        // 修改 display 相對於 slot 的位置、初始縮放等配置
    }
}
​
private fun getReplaceDisplayTextureData(replaceArmatureName: String, replaceDisplayName: String): TextureData {
    val data = factory.getTextureAtlasData(replaceArmatureName)
    data!!.fastForEach { textureAtlasData ->
        val textureData = textureAtlasData.getTexture(replaceDisplayName)
        if (textureData != null) {
            return textureData
        }
    }
    throw Exception("getNewDisplayTextureData null")
}
​
private fun mockTexModel(armatureName: String, displayName: String, imgW: Int, imgH: Int): DragonBonesTexModel {
    val originTexModel = gson.fromJson(texJsonData, DragonBonesTexModel::class.java)
​
    val subTexture: DragonBonesTexModel.SubTexture = run [email protected]{
        originTexModel.subTexture.forEach { subTexture ->
            if (subTexture.name == displayName) {
                [email protected] subTexture.apply {
                    this.x = 0
                    this.y = 0
                }
            }
        }
        throw Exception("Can not find replace display!")
    }
    return DragonBonesTexModel(
        name = armatureName,
        width = imgW,
        height = imgH,
        subTexture = listOf(subTexture)
    )
}
```

包含動畫 vs 不包含動畫

如果換裝的部位不包含動畫,則可以使用圖片做為換裝素材,具體實現方法如上。 如果換裝的部位包含動畫,則可以使用子骨架做為換裝的素材,API呼叫方法和換圖片是一樣的,只不過換進去的是子骨架的顯示物件,在引擎層面,圖片和子骨架的顯示物件都是顯示物件,所以處理起來是一樣的,唯一不同的是子骨架不需要考慮軸點,也不能重新設定軸點,因為他自身有動畫資料相當於已經包含軸點資訊。

  1. 先將原始骨骼動畫檔案中,該slot的display資訊定義為空。例如:

{  "name": "1036",  "display": [   {      "name": "blank"   } ] }, {  "name": "1082",  "display": [   {      "name": "blank"   } ] },

  1. 在子骨架中定義 slot 的 display 資訊。例如:

"slot": [               {                    "name": "1019",                    "parent": "root"               }           ],            "skin": [               {                    "name": "",                    "slot": [                       {                            "name": "1019",                            "display": [                               {                                    "type": "mesh",                                    "name": "glove/2080500b",                                    "width": 159,                                    "height": 323,                                    "vertices": [                                        104.98,                                        -1078.6,                                        108.08,                                        -1094.03                                   ],                                    "uvs": [                                        0.45257,                                        0.1035,                                        0.4721,                                        0.15156,                                        0.4234,                                        0.05575                                   ],                                    "triangles": [                                        7,                                        11,                                        18,                                        20                                   ],                                    "weights": [                                        2,                                        3,                                        0.92                                   ],                                    "slotPose": [                                        1, ​                                        0,                                        0                                   ],                                    "bonePose": [                                        6,                                        0.193207, ​                                        139.903737,                                        -897.076346                                   ],                                    "edges": [                                        19,                                        18,                                        18,                                        20,                                        19                                   ],                                    "userEdges": [                                        16,                                        11,                                        7                                   ]                               }                           ]                       }                   ]               }           ],

  1. 使用子骨架的顯示物件進行替換,以下是使用直接替換 skin 的方式,和替換 display 的原理相同。

private suspend fun replaceDragonBonesDisplay(armatureDisplay: KorgeDbArmatureDisplay) {    val path = "you_xin/suit1/replace/"    val dragonBonesJSONPath = path + "xx_ske.json"    val textureAtlasJSONPath = path + "xx_tex.json"    val textureAtlasPath = path + "xx_tex.png"    // 載入子骨架資料    factory.parseDragonBonesData(Json.parse(res[dragonBonesJSONPath].readString())!!)    factory.parseTextureAtlasData(        Json.parse(res[textureAtlasJSONPath].readString())!!,        res[textureAtlasPath].readBitmap().mipmaps()   )    // 獲取解析後的骨骼資料    val replaceArmatureData = factory.getArmatureData("xx")    // 通過 replaceSkin 的方式修改 slot display    factory.replaceSkin(armatureDisplay.armature, replaceArmatureData!!.defaultSkin!!) }

區域性換裝 vs 全域性換裝

之前說的都是區域性換裝,替換的是紋理集中的一塊子紋理,如果希望一次性替換整個紋理集也是支援的。但是紋理集的配置檔案不能換(如果配置檔案也要換的話,就直接重新構建骨架就好) 也就是說遊戲中可以有一套紋理集配置檔案對應多個紋理集圖片,實現配置檔案不變的情況下換整個紋理集。利用這個技術可以實現例如格鬥遊戲中同樣的角色穿不同顏色的衣服的效果。

全域性換裝之Skin修改

DragonBones支援多套面板的切換,如果面板時固定的,可預先配置在骨骼動畫檔案中,需要時直接切換即可。

private fun changeDragonBonesSkin(armatureDisplay: KorgeDbArmatureDisplay) {    val replaceSkin = factory.getArmatureData("xxx")?.getSkin("xxx") ?: return    factory.replaceSkin(armatureDisplay.armature, replaceSkin) }

全域性換裝之紋理修改

如果面板並未固定的,需要動態配置或者網路下發,那麼可以使用紋理替換的方式。

private suspend fun changeDragonBonesSkin() {    val texDeferred = asyncImmediately { res["body/texture_01.png"].readBitmap().mipmaps() }    factory.updateTextureAtlases(texDeferred.await(), "body") }

總結

對於一款換裝小遊戲來講,使用Spine或者是DragonBones的差異不大,其設計思路基本相同,而且Korge同樣也是支援Spine的渲染。從技術實現上,換裝的功能並不難實現,只是需要考慮的細節方面還有很多,例如:

  • 服裝商城的線上配置和管理,並且有些服裝還可能自帶動畫
  • 某些服裝可能涉及多個插槽,例如:一套裙子,有一部分的層級在身體前面,另一部分的層級在身體後面,那就意味需要兩個插槽才能實現
  • 如果該人物形象在多個介面或者應用中出現,動畫效果不同,但是身上的服裝相同,需要考慮處理換裝後服裝同步的問題