目录
前言
最近在预研一款换装的小游戏,通过在积分乐园中兑换服装,就可以在不同场景中展示穿上新服装的角色。对于这类有主题形象的动画,自然就想到了骨骼动画,通过网格自由变形和蒙皮技术就能在视觉上呈现所需要的动画效果,并且骨骼动画也支持皮肤替换,或者插槽的图片替换,对于换装的需求比较友好。因此决定使用骨骼动画来实现换装小游戏的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的渲染。从技术实现上,换装的功能并不难实现,只是需要考虑的细节方面还有很多,例如:
- 服装商城的在线配置和管理,并且有些服装还可能自带动画
- 某些服装可能涉及多个插槽,例如:一套裙子,有一部分的层级在身体前面,另一部分的层级在身体后面,那就意味需要两个插槽才能实现
- 如果该人物形象在多个界面或者应用中出现,动画效果不同,但是身上的服装相同,需要考虑处理换装后服装同步的问题