Android 上實現DragonBones換裝功能

前言

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

技術選型

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

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

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

Modern Multiplatform Game Engine for Kotlin.

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實現換裝。

在骨骼動畫設計時,每個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
      }
    }
  ]
}

在代碼中,可直接切換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 loop@{
        originTexModel.subTexture.forEach { subTexture ->
            if (subTexture.name == displayName) {
                return@loop 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調用方法和換圖片是一樣的,隻不過換進去的是子骨架的顯示對象,在引擎層面,圖片和子骨架的顯示對象都是顯示對象,所以處理起來是一樣的,唯一不同的是子骨架不需要考慮軸點,也不能重新設置軸點,因為他自身有動畫數據相當於已經包含軸點信息。

先將原始骨骼動畫文件中,該slot的display信息定義為空。例如:

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

在子骨架中定義 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
                                    ]
                                }
                            ]
                        }
                    ]
                }
            ],

使用子骨架的顯示對象進行替換,以下是使用直接替換 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的渲染。從技術實現上,換裝的功能並不難實現,隻是需要考慮的細節方面還有很多,例如:

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

到此這篇關於Android 上實現DragonBones換裝功能的文章就介紹到這瞭,更多相關Android  DragonBones換裝內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: